A signed, versioned capability-negotiation library. CupriMark replaces hard != Version protocol
cutovers with range negotiation, so nodes on different releases keep interoperating through a
deliberate, security-aware migration window instead of partitioning on a flag day.
It is generic and agnostic: it knows nothing about your transport, wire format, or crypto suite. It supplies the catalogue, the negotiation primitive, and the exact bytes to bind into your own authenticated transcript. You define your components and wire the selected version to code paths.
Built for CupriNet as its first consumer, but usable by any protocol that versions independent layers.
-
Only ordinals travel on the wire; their meaning is resolved locally. Peers exchange, per component, the compact range of ordinals they will speak — never version definitions. Each side looks up the agreed ordinal's behaviour in its own signed catalogue.
-
Definitions are immutable; policy is not. Every version splits into an immutable part (
ordinal → { bumpReason, payload }, frozen and hashed at publish) and a per-release policy (the supported range, the lifecycle status, the derived floors). Because a published ordinal is frozen forever, any two releases resolve a shared ordinal to identical behaviour — no trust in the peer required.
| Concept | What it is |
|---|---|
| Catalogue | Immutable, hashed, optionally-signed list of every version each component ever had. Its SHA-256 is the catalogue Id. |
| Ordinal | The uint16 version number used to order and negotiate. How a version is encoded on the wire lives in its opaque payload. |
| BumpReason | Why a version exists — Functionality (free) or Security (raises the floor). Immutable. |
| Status | Where a version is in its life — Active → Deprecated → Buried. Policy; advances forward only. |
| Security floor (soft) | Highest Security ordinal. The default minimum; an override may lower it. |
| Buried floor (hard) | One above the highest buried ordinal. Inviolable — no override crosses it. |
| Override | The operator escape hatch: temporarily lower the soft floor to bridge a migration. Secure by default (off), loud, and time-boxable. |
1. Ship v5 (Security). New nodes' security floor = 5 → by default they refuse the v4 fleet.
2. During migration, set an override on the NEW nodes (acceptFloor = 4). They keep talking to v4.
3. Fleet mostly on v5+ → turn the override off → stragglers get BelowFloor and must upgrade.
4. v4 negligible → Bury it: delete the handler, set status = Buried. Now no config can revive it.
A buried version is three-layers-safe: not offered (excluded from the supported range), not acceptable (the effective floor clamps above it), and not implemented (the handler is deleted, so even a bypass fails closed).
using CupriMark;
// Define a component's history. v3 was a security fix, so the soft floor is 3.
var noiseBinding = new Component("noise-binding",
[
new ComponentVersion(1, BumpReason.Functionality, VersionStatus.Active),
new ComponentVersion(2, BumpReason.Functionality, VersionStatus.Active),
new ComponentVersion(3, BumpReason.Security, VersionStatus.Active, payload: [0x01]),
new ComponentVersion(4, BumpReason.Functionality, VersionStatus.Active),
]);
var catalogue = Catalogue.Create("cuprinet", [noiseBinding]);
// Advertise `catalogue.Component("noise-binding").Supported` on the wire ([1..4] here).
// A peer advertises theirs — say [2..3]. Negotiate locally:
var result = Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(2, 3));
// => Accept(3): the highest mutually-supported ordinal at or above our floor.
// A peer stuck on [1..2] is below our security floor:
Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(1, 2)); // Reject(BelowFloor)
// Bridge it during a migration with a loud, time-boxed override. The clock is a required
// argument — a defaulted clock would silently make every time-boxed override permanent.
var bridge = new FloorOverride { AcceptFloor = 2, ExpiresAtUnixSeconds = migrationDeadline };
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
Negotiator.Negotiate(noiseBinding, OrdinalRange.Create(1, 2), bridge, now); // Accept(2)A supported set is a promise: every ordinal in it must resolve to a defined, non-buried version at or above the buried floor — you never advertise an ordinal you would then refuse to honour. The default is the contiguous run ending at the newest advertisable version (cheap: a couple of bytes on the wire). A build that genuinely needs a gap — speaks v2 and v4 but not v3 — opts into a sparse set:
var codec = new Component("codec", versions, OrdinalSet.Of([2, 4])); // speaks {2,4}, not 3
// Negotiation selects the highest mutually-present ordinal; two peers whose spans overlap
// but share no actual version get Reject(NoCommonVersion). Contiguous stays the default and
// is byte-for-byte unchanged on the wire.A signed catalogue authenticates definitions, not the live negotiation. To stop an active MITM rewriting the advertised ranges, bind the negotiation into your own authenticated transcript (e.g. mix it into a Noise prologue / handshake hash). Bindings are keyed by handshake role — initiator vs responder — so both peers, fed the same honest wire data, derive byte-identical blobs:
// Each side builds the binding from its own view; `isInitiator` maps (local, peer) onto the
// canonical (initiator, responder) slots. Only an accepted negotiation is bindable — a
// rejection aborts the handshake instead.
var binding = NegotiationBinding.FromLocal(
"noise-binding",
localRange: noiseBinding.Supported,
peerRange: peerRange,
result: result,
isInitiator: weInitiatedTheHandshake);
// Bind ALL the components you negotiated — a partial binding leaves the rest MITM-able.
// EncodeComplete fails loudly if any negotiated component is missing; Digest gives a
// fixed-size 32-byte value to mix into a running handshake hash.
byte[] digest = TranscriptBinding.Digest(allBindings, negotiatedComponentNames);
// Feed `digest` into your handshake hash / Noise prologue. Both honest sides derive the same
// digest; tampering with either advertised range diverges it and breaks the MAC.See TranscriptBindingScenarioTests for a
worked initiator/responder/MITM example: an honest exchange yields matching transcript hashes, while a
MITM rewriting an advertised range diverges them even though the negotiation itself still "accepts".
The core library carries no crypto dependency: plug in Ed25519 via ICatalogueSigner /
ICatalogueVerifier, and CupriMark hands over the exact canonical body to sign and stores the result.
For a batteries-included, 100% managed option, add the CupriMark.Signing.CupriCurve companion
package (Ed25519 over CupriCurve); or bridge your own stack. Consumers pin the release key before trusting any definition.
// With the companion package:
var signer = CupriCurveSigner.Generate(out byte[] seed); // persist `seed` (the private key)
var signed = CatalogueSigning.Sign(catalogue, signer);
bool ok = CatalogueSigning.Verify(signed, CupriCurveVerifier.Instance, pinnedKey: signer.PublicKey);Key rotation without a flag day. Pin an ordered set of keys (current + still-trusted previous) so you can ship a new key, sign with it while the fleet still trusts the old one, then retire the old key:
var pins = new PinnedKeys(currentKey, previousKey); // both trusted during the overlap
bool ok = CatalogueSigning.Verify(signed, myVerifier, pins);
// roll forward: pins = pins.Rotate(nextKey); then later: pins = pins.Retire(previousKey);The catalogue is generated at build time from a reviewed cuprimark.json manifest — never hand-assembled
at runtime — and gated by a lockfile that enforces immutability.
# Build the catalogue, enforce the immutability gate against the lock, and emit both.
cuprimark build --manifest cuprimark.json --out catalogue.bin --lock cuprimark.lock
# Inspect a built catalogue.
cuprimark inspect catalogue.bin
# Dry-run the negotiation primitive against a peer's advertised range.
cuprimark negotiate --catalogue catalogue.bin --component noise-binding --peer 1-2 --accept-floor 1The immutability gate fails the build if a previously-locked version's immutable definition changed
("ordinal N is published and immutable"). Adding a version or advancing a status is allowed and updates
the lock, so the change is reviewable in the pull request. See samples/cuprimark.json.
Add the CupriMark.Build.Tasks package and the catalogue is generated as part of dotnet build —
the immutability gate runs on every compile, and the catalogue (plus advisory/dependency companions) is
embedded as a resource on your assembly:
<PackageReference Include="CupriMark.Build.Tasks" Version="..." PrivateAssets="all" />Drop a cuprimark.json next to the project; the build reads it, enforces the gate (a violation fails
the build with a reviewable error), updates cuprimark.lock in place only when it changes, and embeds
catalogue.bin (load it at runtime with Catalogue.Decode(assembly.GetManifestResourceStream("CupriMark.catalogue.bin"))).
Override CupriMarkManifest / CupriMarkLock / CupriMarkResourcePrefix as needed.
CupriMark only pays off if the whole team follows one discipline: a version, once published, is frozen forever; only policy moves. These are the practices that keep that true.
- Keep a single
cuprimark.jsonper project as the reviewed source of truth. Never hand-assemble a catalogue at runtime. - Run
cuprimark build --manifest cuprimark.json --lock cuprimark.lockin CI on every PR, and commitcuprimark.lock. The immutability gate turns "someone quietly changed a shipped version" from a production incident into a failed check with a reviewable diff. - Treat a gate failure as a design signal, not an obstacle to silence: it means you tried to mutate history. Add a new ordinal instead.
To change behaviour, append a new ordinal; never edit a published one. Cutting a version is two decisions:
bumpReason— the decision that matters most. Ask: does an older node speaking the previous version expose a vulnerability?Functionality— a feature or behaviour change with no security implication. The old versions stay freely negotiable; nobody is forced to upgrade. Use this for the overwhelming majority of bumps.Security— a fix where continuing to speak the old version is unsafe. This raises the soft floor, so fresh nodes refuse everything below it by default. Reserve it for genuine security regressions; over-using it forces needless flag-day-like churn.- When unsure, default to
Functionality. If a flaw is found in a version you already shipped asFunctionality, you cannot rewrite its frozen tag — that is what the planned retroactive advisory list (below) is for. Do not try to "fix" it by editing the manifest; the gate will stop you, and correctly.
payload— put anything version-specific here (the on-wire tag, capability flags, a human label). It is opaque to CupriMark and immutable once published, so design it to be self-describing.
status is the one field that legitimately changes release to release, and only ever forward:
| Move | When | Effect |
|---|---|---|
| → Deprecated | A later Security bump superseded this version. |
Still implemented, but only negotiable via an explicit override. Signals "on its way out." |
| → Buried | The version is negligible in the fleet and you have deleted its handler code. | Permanently un-negotiable; the ordinal is tombstoned and can never be recycled. |
Bury only after the code path is actually removed — burying is a promise that there is nothing left to run. The tombstone stays in the catalogue forever so history is auditable and the ordinal is reserved.
This is the whole point — rolling out a Security bump without partitioning the network:
- Ship the new version (say ordinal 5,
Security). New nodes' floor is now 5; by default they would refuse the v4 fleet. - Bridge. Set a per-component, time-boxed override on the new nodes only:
acceptFloor = 4,expiresAta few weeks out. Old nodes need no override — their floor is already at or below the selected version. - Drain. As the fleet moves to v5+, watch the
sessions-below-security-floorcounter you emit while an override is live. When it approaches zero, remove the override. Stragglers now getBelowFloorand are pushed to upgrade. - Bury. Once v4 traffic is gone, delete the v4 handler and set
status: Buried. Now no config, stale or malicious, can revive it.
The override is an escape hatch, not a setting:
- Off by default. A fresh deployment must be secure with no configuration.
- Per-component, so you bridge one weak layer without lowering the floor on everything else.
- Loud. While one is active, emit a standing warning and the below-floor session counter so it can't
quietly become permanent. Wire
ICupriMarkObserver.OnBelowSecurityFloorAccepted(passed toNegotiator.Negotiate) to your telemetry — it fires once whenever an override bridges an acceptance below the security floor. (CatalogueDiagnostics.Compare+OnCatalogueIdMismatchsimilarly surface a catalogue fork, without affecting negotiation.) - Time-boxed. Always set
expiresAt; renewing it should be a conscious act. Remember wall-clock expiry is an operational nicety on a hostile host, not a security control — the real forcing function is burying the old version. - Delete it the moment the migration is done. An override that outlives its migration is technical debt with a security label.
bumpReason only captures security issues known at publish time. When a flaw is later found in a
version that shipped as a mere Functionality bump, its frozen tag can't be edited — and it shouldn't be,
or immutability is meaningless. Instead, publish an advisory: a note in a separate, append-only,
signable companion list that condemns an already-published ordinal after the fact.
An advisory on ordinal N raises the effective security floor to N+1 for that component — exactly as a
planned Security bump would — so negotiation refuses ≤N by default:
var advisories = AdvisoryList.Decode(File.ReadAllBytes("advisories.bin"));
Negotiator.Negotiate(component, peerRange, advisories, overridePolicy: bridge, now);The advisory list is append-only: the build gate refuses to retract or edit a published advisory (retracting one would silently re-open a downgrade an operator was told was closed). An override can still bridge below an advisory floor during migration, down to — never past — the buried floor, and burying the condemned version remains the terminal fix.
- Sign the catalogue with your release key and pin that key in the consumer. Verification against the pin is what makes "same ordinal ⇒ same meaning" safe against a tampered local copy — verify before trusting any definition.
- Keep the signing key in your release pipeline's secret store (or an HSM), never in the repo. A project that already embeds the catalogue inside an independently-signed binary may skip catalogue signing.
Some version selections are only valid together — "v3 toll requires ≥v2 noise-binding". Declare such
constraints and evaluate them after per-component negotiation; an unmet dependency fails the session closed.
// in cuprimark.json
"dependencies": [
{ "ifComponent": "toll", "ifMinOrdinal": 3, "requiresComponent": "noise-binding", "requiresMinOrdinal": 2 }
]var deps = DependencySet.Decode(File.ReadAllBytes("dependencies.bin"));
var evaluation = deps.Evaluate(perComponentResults); // Dictionary<string, NegotiationResult>
if (!evaluation.Satisfied)
Reject(evaluation.Violations); // fail closed with a specific reasonRules are local policy resolved on each side (nothing travels on the wire); cuprimark build validates
them against the catalogue, and cuprimark check --select toll=3,noise-binding=1 evaluates a selection.
CupriMark authenticates definitions; it does not, by itself, stop an active MITM from rewriting the
live advertised ranges. For every negotiation that guards a secret, feed
TranscriptBinding.Encode(...) into your channel's authenticated transcript. Negotiation that runs
before your channel is authenticated (e.g. a pre-handshake cookie) is inherently unprotected — keep such
components non-security-critical and document it.
- Behaviour change expressed as a new ordinal, never an edit to a published one.
-
bumpReasonchosen deliberately (Securityonly for real regressions). -
cuprimark buildrun in CI;cuprimark.lockdiff reviewed and committed. - Any status advance (Deprecate/Bury) intentional and forward-only; Bury only after the handler is deleted.
- Migration overrides per-component, time-boxed, loud — and scheduled for removal.
- Catalogue signed and the key pinned; negotiation bound into the authenticated transcript.
-
Downgrade (active MITM): not prevented by the signature alone — bind the offered ranges (both directions) and the selection into your authenticated transcript. CupriMark gives you the canonical bytes.
-
Self-downgrade (honest weak peer): bounded by your local security floor; a peer can't drag you below it unless you enabled the override.
-
Tampered local catalogue (supply chain): verify the signature against your pinned release key.
-
Resource abuse: hard
ParseLimitscaps on components, versions, payload size, name length, and range width when decoding untrusted bytes. Malformed input is rejected, never silently clamped. -
Fingerprinting / anonymity: advertising your exact supported set leaks how old your build is (how far back it still speaks). For anonymity-sensitive transports (e.g. Tor mode), advertise the coarse range instead —
[effectiveFloor..max], hiding the tail below the floor at no functional cost:var floor = Negotiator.EffectiveFloor(component, advisories?.SecurityFloorFor(component.Name) ?? 0, overridePolicy, now); OrdinalRange onWire = Advertisement.For(component, AdvertisementPolicy.Coarse, floor); // or: Advertisement.For(component, AdvertisementPolicy.Coarse, advisories, overridePolicy, now)
| Path | Contents |
|---|---|
src/CupriMark |
The library: data model, canonical encoder + hash, negotiation, floors, override, transcript binding, signing abstraction, build/lock tooling. |
src/CupriMark.Cli |
The cuprimark build/inspect/negotiate/advertise/check CLI. |
src/CupriMark.Signing.CupriCurve |
Optional Ed25519 signer/verifier over CupriCurve. |
src/CupriMark.Build.Tasks |
Optional MSBuild task: generate + gate + embed the catalogue during dotnet build. |
tests/ |
xUnit suites (core, signing companion, build task). |
samples/cuprimark.json |
An example manifest (components, advisories, dependencies). |
See ROADMAP.md for what has shipped and what's next.
Everything on the roadmap through v0.4 has shipped: the v0.1 core (data model, canonical encoding + hash,
range negotiation, Active/Deprecated/Buried lifecycle, floors, override, transcript binding, lockfile
gate, parse wards) plus retroactive advisories, transcript-binding hardening, key pinning & rotation, a
bundled Ed25519 companion, coarse-advertisement anonymity mode, sparse/non-contiguous support,
inter-component dependency rules, catalogue-id diagnostics, observability hooks, and an MSBuild build task.
MIT © Wixely